前言
最近在用 Elixir
重寫阿瓦隆的伺服器,簡單記錄利用使用 Phoenix
這個框架使用 Socket
的心得。
新建
安裝好 Elixir
和 Phoenix
後先新增一個專案,在這邊先不用到資料庫因此先不安裝 Ecto
。
1
| mix phoenix.new avalon_backend --no-ecto
|
遊戲大廳及使用者
在 avalon_backend.ex
新增一個 worker
用來開啟 GenServer
。
1 2 3 4 5
| children = [ supervisor(AvalonBackend.Endpoint, []), worker(AvalonBackend.UserModel, [%{}]) ]
|
讓伺服器監聽 game:lobby
這個 channel
且讓 socket 在連接的時候給予 id
以便於之後的使用。
1 2 3 4 5 6 7 8 9
| channel "game:lobby", AvalonBackend.LobbyChannel def connect(_params, socket) do id = Enum.random(0..1000) user = %{ :id => id } socket = assign(socket, :user, user) {:ok, socket} end
|
在 lobby_channel.ex
定義 channel 在特定事件中所會觸發的事件。
使用者加入會將使用者保存起來,若離開則會移除該使用者,並將當前在 game:lobby
頻道的使用者 broadcast
給該頻道的所有人。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| defmodule AvalonBackend.LobbyChannel do use AvalonBackend.Web, :channel alias AvalonBackend.UserModel def join("game:lobby", _payload, socket) do user = socket.assigns.user users = UserModel.user_joined("game:lobby", user)["game:lobby"] send self(), {:after_join, users} {:ok, socket} end def terminate(_reason, socket) do user_id = socket.assigns.user.id users = UserModel.user_left("game:lobby", user_id)["game:lobby"] lobby_update(socket, users) :ok end def handle_info({:after_join, users}, socket) do lobby_update(socket, users) {:noreply, socket} end defp lobby_update(socket, users) do broadcast! socket, "lobby_update", %{ users: get_users_id(users) } end defp get_users_id(nil), do: [] defp get_users_id(users) do Enum.map users, &(&1.id) end end
|
由於 Elixir 沒有全域變數,在儲存變數的需求下我們必須透過 GenServer
, 在這邊利用 Map
型態來保存所有使用者。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| defmodule AvalonBackend.UserModel do use GenServer def start_link(initial_state) do GenServer.start_link(__MODULE__, initial_state, name: __MODULE__) end def user_joined(channel, user) do GenServer.call(__MODULE__, {:user_joined, channel, user}) end def user_left(channel, user_id) do GenServer.call(__MODULE__, {:user_left, channel, user_id}) end def handle_call({:user_joined, channel, user}, _from, state) do new_state = case Map.get(state, channel) do nil -> Map.put(state, channel, [user]) users -> Map.put(state, channel, Enum.uniq([user | users])) end {:reply, new_state, new_state} end def handle_call({:user_left, channel, user_id}, _from, state) do new_users = state |> Map.get(channel) |> Enum.reject(&(&1.id == user_id)) new_state = Map.update!(state, channel, fn(_) -> new_users end) {:reply, new_state, new_state} end end
|
在 client 端引入自己攥寫的 socket.js
1 2
| import socket from "./socket"
|
首先引入來自 Phoenix
的 Socket
,讓該 socket 連接 game:lobby
,並監聽 lobby_update
事件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| import {Socket} from "phoenix" let socket = new Socket("/socket") socket.connect() let channel = socket.channel("game:lobby", {}) channel.on('lobby_update', function(resp) { console.log(resp); }); channel.join() .receive("ok", resp => { console.log("Joined successfully", resp) }) .receive("error", resp => { console.log("Unable to join", resp) }) export default socket
|
傳送訊息給特定使用者
透過將不同使用者加入到自己獨立的 channel
,透過 broadcast
該頻道的方式來對該使用者發出事件。
在 Server 端產生完 id 後,回傳 id 給 client 端。
1 2 3 4 5 6 7
| def join("game:lobby", _payload, socket) do user = socket.assigns.user users = UserModel.user_joined("game:lobby", user)["game:lobby"] send self(), {:after_join, users} {:ok, %{ id: user.id }, socket} end
|
當 client 端接受 id 後則連接該 id 的頻道 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| import {Socket} from "phoenix" let socket = new Socket("/socket") socket.connect() let channel = socket.channel("game:lobby", {}) let userChannel; channel.on('lobby_update', function(response) { console.log(response); }); channel.join() .receive("ok", resp => { console.log("Joined successfully", resp) userChannel = socket.channel("user:" + resp.id); userChannel.on("message", msg => console.log(msg) ) userChannel.join() .receive("ok", resp => console.log("joined private user channel") ) .receive("error", err => console.log(err)); }) .receive("error", resp => { console.log("Unable to join", resp) }) export default socket
|
新增使用者專屬的 channel ,並當接受 message 事件時,發送訊息到該指定 user 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| channel "user:*", AvalonBackend.UserChannel defmodule AvalonBackend.UserChannel do use AvalonBackend.Web, :channel def join("user:" <> _id, _payload, socket) do {:ok, socket} end def handle_in("message", %{"id" => id, "message" => message }, socket) do AvalonBackend.Endpoint.broadcast "user:" <> id, "message", %{ message: message } {:noreply, socket} end end
|
在 client 新增輸入欄讓使用者可以輸入 id 及 message 來發送。
1 2 3 4
| <input id="idInput" placeholder="id"></input> <input id="messageInput" placeholder="message"></input> <button id="submitButton">Submit</button>
|
1 2 3 4 5 6 7 8 9 10
| document.getElementById('submitButton').addEventListener('click', () => { let args = { id : document.getElementById('idInput').value, message : document.getElementById('messageInput').value } userChannel.push('message', args) .receive('ok', () => console.log('success')) .receive('error', (e) => console.log(e)); })
|
如此一來基本的 Server 功能就完成了。
GitHub
avalon-ng/avalon_backend
參考
Creating a Game Lobby System in Phoenix with Websockets